深度长文 | 循序渐进解读计算机中的时间—应用篇(上)
本文字数:6320字
预计阅读时间:25分钟
导读
什么是时间?这是一个物理概念和哲学问题。
物理学认为时间是一种尺度,一个标量,借着时间,事件发生之先后可以按过去-现在-未来之序列得以确定(时间点),进而事件之间的间隔长短亦得以衡量(时间段)。
哲学上认为时间是宇宙的基本结构,是一个会依序列方式出现的维度。或主张时间“本身并不存在,而是我们表达事物方式的产物”。
计算机科学是建立在现实物理世界的基础上的,要尽量匹配地球自转公转的结果,同时要匹配一系列人为规定的概念(如时区、夏令时)。
这就带来了一系列问题:计算机如何描述及存储时间点和时间段、如何匹配不同时区和计时方式、如何转换时间的表示方法、如何获取当前时间、如何控制时间精度、如何感知时间流逝等一系列问题。
时间问题浩如烟海,本篇文章尽笔者能力清晰深入地“捡拾”一些重点,探究这类问题。
1 常识知识
1.1
时区
时区是地球上的同一块区域使用的同一个时间定义。世界各个国家位于地球不同位置上,因此不同国家,特别是东西跨度大的国家日出、日落时间必定有所偏差。这些偏差就是所谓的时差。
1.2
夏令时
所谓“夏令时”(Daylight Saving Time,简称D.S.T.),是指在夏天太阳升起的比较早时,将时钟拨快一小时,以提早日光的使用。
这个构想于1784年由美国班杰明·富兰克林提出来,1915年德国成为第一个正式实施夏令日光节约时间的国家,以削减灯光照明和耗电开支。进夏令时时间要拨快一小时,出夏令时时间再拨回来,但这跟UTC或GMT完全没有关系,完全是人为行为。
1.3
UTC和GMT
●UTC是“协调世界时”(Universal Time Coordinated)的英文缩写,是由国际无线电咨询委员会规定和推荐,并由国际时间局(BIH)负责保持的以秒为基础的时间标度。个人理解为按规定的统一计量单位延伸的时间标度。
●GMT(Greenwich Mean Time)是格林尼治平均时间。由于地球轨道并非圆形,其运行速度又随着地球与太阳的距离改变而出现变化。在格林尼治子午线上的平太阳时称为世界时(UT0),又叫格林尼治平时(GMT)。个人理解为实际观测计算不受人为控制的太阳运行周期的时间标度。
●若以“世界标准时间”的角度来说,UTC比GMT来得更加精准。两者误差值必须保持在0.9秒以内,若大于0.9秒则由位于巴黎的国际地球自转事务中央局发布闰秒,使UTC与地球自转周期一致。
2 Java关于日期时间的获取、表示及格式转换
时间的表示可以分为时间点和时间段。时间点又可以分为“相对时间”和“绝对时间”(不是相对论里那个),人们一般理解表述的“现在几点”、“挂钟上显示什么时间”是“相对时间”,即本地时间,是没有时区属性的。但是如果要表示一个客观发生的时间点就要用到“绝对时间”,这个时间点在每个时区的挂钟上显示的都不同。
时间的表示还关系到精度问题,如精确到天、秒还是毫秒、纳秒,都有不同的表示方法。
下面以Java为例,较为详细地介绍关于日期时间的获取、表示及格式转换方法。
2.1
System.currentTimeMillis()
这是我们最常用的获取当前时间的方法,静态方法System.currentTimeMillis() 返回UTC时间从1970年1月1日00:00到现在的总毫秒数,返回类型为long。我们所有需要做的就是一行代码:
1Long time = System.currentTimeMillis();
ps:为什么是从1970年1月1日开始?
Unix是1969年发布的雏形,最早是基于硬件60Hz的时间计数。1971年底出版的《Unix Programmer’s Manual》里定义的Unix Time是以1971年1月1日00:00:00作为起始时间,每秒增长60。
之后考虑到32位整数的范围,如果每秒60个数字,则两年半就会循环一轮。于是改成了以秒为计数单位。这个循环周期有136年之长,就不在乎起始时间是1970还是1971年了,于是就改成了人工记忆、计算比较方便的1970年。
趣闻:32位Unix时间戳的范围是 1971年1月1日00:00:00 ~ 2038年1月19日03:14:07(UTC),超过这一范围则会越界。2016年出现过苹果用户将手机时间设为1971年之前,然后iPhone变砖了。现在iPhone的解决方法是不允许手动设置年份。
注意,java.lang包在该方法的注释中提到,当返回值的时间单位是毫秒时,值的粒度取决于底层操作系统,可能粒度会大于1ms。同时高并发场景下要小心该方法的性能消耗。
为什么会这样?什么时候会出现这种情况?下篇会从该方法的源码入手深入探究。
2.2
System.nanoTime()
Java7的API文档中说明:该方法返回正在运行的Java虚拟机的高分辨率时间源的当前值,以纳秒为单位。此方法只能用于测量经过的时间,与系统或钟表时间等任何其他概念无关。在同一个Java虚拟机实例中,此方法的所有调用都使用相同的时间原点,其他虚拟机实例可能使用不同的时间原点。此方法提供纳秒级精度,但不一定是纳秒级分辨率,但是最少和 currentTimeMillis() 方法的分辨率一样高。
也就是说,nanoTime() 方法返回的数字绝对值没有意义,仅当计算在Java虚拟机的同一实例中获得的两个此值之间的差异时,此方法返回的值才有意义。常用的方法是:
1Long startTime = System.nanoTime();
2doSomething();
3Long estimatedTime = System.nanoTime() - startTime;
那所谓的“随机起点”在不同平台上是如何实现的?System.nanoTime() 和 System.currentTimeMillis() 有没有什么关系?也会在下篇中一并提及。
2.3
java.util.Date
Date是Java最早提供的用来封装日期时间的类,由于不易于国际化且很多参数计算不符合日常认知或不正确(具体可以见源码),很多获取年、月、日、小时等数据的方法都过时了不推荐使用(@Deprecated),被Calendar类的方法代替。
这里选一些还在使用的关键字段和方法进行说明。
Date类有两个关键的成员变量:
1// 记录当前时间戳
2private transient long fastTime;
3
4/*
5 * cdate对象是 BaseCalendar.Date类,继承自sun.util.calendar.CalendarDate。
6 * 包含很多已计算好的日期时间相关变量,如 dayOfWeek(所在星期的第几天)、leapYear(是否是闰年)等。
7 * 如果 cdate 对象为空,用 fastTime 变量代表精确到毫秒的时间。
8 * 如果 cdate.isNormalized() 方法返回 true,则 fastTime 和 cdate 已经同步过。
9 * 如果 cdate.isNormalized() 方法返回 false,则忽略 fastTime 的值,使用 cdate 代表时间。
10 */
11private transient BaseCalendar.Date cdate;
Date类提供的两个构造函数,看源码清晰明了:
1// 无参构造方法,创建当前时间的Date类
2public Date() {
3 this(System.currentTimeMillis());
4}
5// 传入一个Unix时间戳,创建特定时间的Date类
6public Date(long date) {
7 fastTime = date;
8}
9// 其他通过年月日创建的构造方法已被 Calendar.set() 和 DateFormat.parse() 等方法替代,不再展示
Date类型存储日期时间实际存储的是Unix时间戳,所以可以表示绝对时间,支持绝对时间的比较。典型的Date类型数据结构如下图:
一个小问题:上文我们看到构造方法中并没有赋值 cdate 变量,那么调试的时候显示的 cdate 是如何被初始化的呢?答案是:IDE调试的时候为了显示变量值,调用了 toString 方法,至于为什么会初始化,参考该类 toString() 方法源码。
Date类还有很多常用的成员方法,可以用 long getTime( ) 和 void setTime(long time) 进行该Date对象日期时间的获取和设定(毫秒级别);可以用 boolean after(Date date)、boolean before(Date date)、int compareTo(Date date)、boolean equals(Object date)等方法比较两个日期时间的先后顺序。具体的比较简单,不展开详述。
2.4
java.sql下的时间日期类
java.sql.Date、java.sql.Time 和 java.sql.Timestamp 都继承自 java.util.Date 类,是专门用于数据库连接的。由于继承关系,从数据结构来看和它们的父类区别不大。
最主要的区别在于 Timestamp 类可以表示至纳秒级,其 fastTime 字段从秒之后被截掉,毫秒至纳秒精度保存在特有的 nanos 字段中。可参考下图:
但是要注意 Timestamp 类的纳秒精度可能是“假的”,构造方法源码如下:
1public Timestamp(long time) {
2 super((time/1000)*1000);
3 nanos = (int)((time%1000) * 1000000);
4 if (nanos < 0) {
5 nanos = 1000000000 + nanos;
6 super.setTime(((time/1000)-1)*1000);
7 }
8}
可以看出,在将 fastTime 字段强行截掉之后,进行毫秒值直接乘1,000,000的操作后赋给了 nanos 字段,成为了“只能表示到毫秒的纳秒级精确度”。当然,还可以通过 setNanos(int n) 方法给纳秒数赋精确值。
虽然数据结构看来没什么特别,但是如果涉及到Timestamp类的父子类型转换或时间的比较,就要小心一些“坑”。
1、equals() 方法的不对称性java.sql.Timestamp 类和其父类 java.util.Date 的 equals() 方法是不符合对称性的。举例如下:
这是由于java.sql.Timestamp 类的 equals() 方法对于非本类的实例直接返回false,jdk中给出了解释:
The Timestamp.equals(Object) method never returns true when passed an object that isn't an instance of java.sql.Timestamp, because the nanos component of a date is unknown. As a result, the Timestamp.equals(Object) method is not symmetric with respect to the java.util.Date.equals(Object) method. Also, the hashCode method uses the underlying java.util.Date implementation and therefore does not include nanos in its computation.意为:传递一个不是java.sql.Timestamp实例的对象时,Timestamp.equals(Object)方法永远不会返回true,因为日期的nanos组件是未知的。
因此,Timestamp.equals(Object)方法与java.util.Date.equals(Object)方法不对称。
此外,hashCode方法使用底层的java.util.Date实现,因此在其计算中不包括nanos。
equals() 源码如下:
1public boolean equals(java.lang.Object ts) {
2 if (ts instanceof Timestamp) {
3 return this.equals((Timestamp)ts);
4 } else {
5 // 非Timestamp类型直接返回false
6 return false;
7 }
8}
9// Timestamp类型的equals判断
10public boolean equals(Timestamp ts) {
11 if (super.equals(ts)) {
12 if (nanos == ts.nanos) {
13 return true;
14 } else {
15 return false;
16 }
17 } else {
18 return false;
19 }
20}
2、时间比较类方法的“异常”现象举例如下,两个有毫秒之差的时间点,after() 方法返回不符合客观事实:
探究其原因。父类 java.util.Date 中 after() 方法的实现如下:
1public boolean equals(java.lang.Object ts) {
2public boolean after(Date when) {
3 return getMillisOf(this) > getMillisOf(when);
4}
java.sql.Timestamp 类没有重写 after(Date d) 方法,只写了after(Timestamp t) 方法,如下:
1public boolean after(Timestamp ts) {
2 return compareTo(ts) > 0;
3}
所以上图传参为 java.util.Date 类,程序走的是父类的 after() 方法,而 java.sql.Timestamp 类也没有重写 getMillisOf() 方法,所以也是使用父类的:
1static final long getMillisOf(Date date) {
2 if (date.cdate == null || date.cdate.isNormalized()) {
3 return date.fastTime;
4 }
5 BaseCalendar.Date d = (BaseCalendar.Date) date.cdate.clone();
6 return gcal.getTime(d);
7}
上文有提到,java.util.Date 会对 fastTime 和 cdate 进行同步,由于 Timestamp 类在其继承父类的 fastTime 和 cdate 变量中不存储毫秒数据,所以调用父类的 after() 方法时, 只有毫秒差异的时间调用 getMillisOf() 方法返回的结果是相同的。所以,java.sql.Timestamp 向父类 java.util.Date转型时会丢失毫秒。
JDK文档中对此的说明为:
Due to the differences between the Timestamp class and the java.util.Date class mentioned above, it is recommended that code not view Timestamp values generically as an instance of java.util.Date. The inheritance relationship between Timestamp and java.util.Date really denotes implementation inheritance, and not type inheritance.
意为:建议代码不要将 Timestamp 值一般视为java.util.Date的实例。Timestamp 和 java.util.Date 之间的继承关系实际上表示实现继承,而不是类型继承。
如果不确定类型的情况下要进行时间的比较,尽量使用 compareTo() 方法,可以保证正确性。
2.5
java.util.Calendar
Calendar类是一个日历抽象类,提供了一组对年月日时分秒星期等日期信息的操作的函数,并针对不同国家和地区的日历提供了相应的子类,即本地化。
比如公历 GregorianCalendar ,佛历(泰国使用)BuddhistCalendar,日本历 JapaneseImperialCalendar 等(没有中国农历太不友好了=_=)。
从JDK1.1版本开始,在处理日期和时间时系统推荐使用Calendar类进行实现。在设计上,Calendar类的功能要比Date类强大很多,而且在实现方式上也比Date类要复杂一些。
首先我们来直观地看一下Calendar类能表示些什么,打印一个新建的Calendar实例:
1// 代码:
2Calendar calendar = Calendar.getInstance();
3System.out.println(calendar);
4
5// 打印结果,字段含义都是字面意思:
6java.util.GregorianCalendar[
7 time=1564912275912,
8 areFieldsSet=true,
9 areAllFieldsSet=true,
10 lenient=true,
11 zone=sun.util.calendar.ZoneInfo[
12 id="Asia/Shanghai",
13 offset=28800000,
14 dstSavings=0,
15 useDaylight=false,
16 transitions=19,
17 lastRule=null
18 ],
19 firstDayOfWeek=1,
20 minimalDaysInFirstWeek=1,
21 ERA=1,
22 YEAR=2019,
23 MONTH=7,
24 WEEK_OF_YEAR=32,
25 WEEK_OF_MONTH=2,
26 DAY_OF_MONTH=4,
27 DAY_OF_YEAR=216,
28 DAY_OF_WEEK=1,
29 DAY_OF_WEEK_IN_MONTH=1,
30 AM_PM=1,
31 HOUR=5,
32 HOUR_OF_DAY=17,
33 MINUTE=51,
34 SECOND=15,
35 MILLISECOND=912,
36 ZONE_OFFSET=28800000,
37 DST_OFFSET=0
38]
Calendar类可以通过静态工厂方法或new子类的方式来获得实例:
1、getInstance()方法,有四个重载方法,参数是时区和地区,如果不传会取服务器默认的时区和地区。(地区现在是专门为了区分泰国和日本)
1.1 getInstance()
1.2 getInstance(TimeZone zone)
1.3 getInstance(Locale aLocale)
1.4 getInstance(TimeZone zone,Locale aLocale)
2、新建子类对象
1Calendar calendar = new GregorianCalendar();
Calendar类可以实现带时区的年月日时分秒星期等对Unix时间戳的转换,内部通过子类复杂的 computeTime() 方法进行计算。可以使用 getTime() 方法返回 java.util.Date 类型的时间,可以使用 getTimeInMillis() 方法返回当前Unix时间戳,也可以通过 get(int field) 方法获取其他年月日等单独信息,部分可用 field 列表如下:
也可以通过多个 set 重载方法设定各种值。同时, add() 方法支持对单个值的加减,从而实现时间推移的计算,传入负数即为减,示例如下:
GregorianCalendar 对象可以直接使用 isLeapYear(int year) 接口判断是否闰年。要注意两个设定上的问题:在 Calendar 中 MONTH 这个域并不是从1到12的,而是0表示一月,11表示十二月。DAY_OF_WEEK 域星期天是1,星期一是2,依次类推。为了避免用错,Calendar 类已经为我们定义好了常量,如一月可以直接Calendar.JANUARY 。
2.6
java.text.SimpleDateFormat
SimpleDateFormat 是一个以语言环境敏感的方式来格式化和分析日期的类。SimpleDateFormat 允许选择任何用户自定义的日期时间格式来运行。如:
还有更多可表示的模式,对应符号不在此给出。
值得一提的是,在后端接口开发时,接口返回的日期时间格式可能是和框架序列化方式有关的。如 springboot 中使用 jackson 作为默认的 json 工具,不同版本 jackson 对于日期时间的默认序列化方式不同。
1.5.10.RELEASE 版本的 springboot 默认 2.8.10 版本的 jackson,Date类返回的默认格式是Unix时间戳;2.0.5.RELEASE 版本的 springboot 默认 2.9.6 版本的 jackson,Date类返回的默认格式类似 "2019-08-04T13:43:21.535+0000" 。如果想规定返回格式可以在 spring 中配置,或直接使用 SimpleDateFormat 格式化成 String 后再返回。
2.7
Java7中日期时间类的线程安全问题
症状如下图,开多个线程使用同一个 SimpleDateFormat 实例,会出现解析失败:
说明在多线程场景下 SimpleDateFormat 是有线程安全问题的。究其原因,SimpleDateFormat 类继承自 DateFormat 类,DateFormat 实例中维护了一个 Calendar 对象,parse() 方法会调用 Calendar 对象的方法去根据给定格式设置属性值,而 Calendar 对象的 fields、time、zone 等表示字段都是线程不安全的。如果 SimpleDateFormat 是单例,Calendar 对象一定也是多线程共用一个的。
解决方法:
1、使用局部变量这也是我们常用的方法,每次请求新建一个 SimpleDateFormat 的实例。虽然常用,但是实际开销是较大的;
2、给 parse() 方法加 synchronized既然是由于调用 Calendar 设置时出的线程安全问题,加锁当然可以解决。但是系统性能会下降,权衡利弊个人认为还不如1方法;
3、使用 ThreadLocal 为每个线程维护一个 SimpleDateFormat 实例,起码同一线程内可以共享一个实例减少了不少开销,上述代码可修改如下:
1public class Main {
2 private static ThreadLocal<DateFormat> sdfThreadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
3
4 public static void main(String[] args) {
5 for (int i = 0; i < 100; ++i) {
6 Thread thread = new Thread(() -> {
7 try {
8 System.out.println(sdfThreadLocal.get().parse("2019-08-04 22:17:27"));
9 } catch (Exception e) {
10 System.out.println("解析失败");
11 }
12 });
13 thread.start();
14 }
15 }
16}
2.8
Java8 中的新类型
由于旧版 Java 中的日期时间 API 存在线程不安全、某些设计不符合日常直觉、时区处理复杂等问题,Java8 中提供了一些新的 API。包括Instant、LocalDate、LocalTime、LocalDateTime、ZonedDateTime、Period、Duration、DateTimeFormatter等。
首先直观看一下这些类里都有什么:
2.8.1 Instant
Instant,中文可译为“瞬间”,表示了时间线上一个确切的点,可以表示纳秒级别的时刻(虽然 now() 构造方法得出的纳秒数和 java.sql.Timestamp 类一样也是“假的”,是从 System.currentTimeMillis() 得来的)。
Instant是时区无关的,如何理解这个“时区无关”?即始终是对标协调世界时(UTC)即格林尼治零时区的,个人觉得可以理解为“Unix时间戳的更精确表示形式”。Instant 类有四种实例化方法:
由上上图可知,Instant 对象中保存了 seconds(距离初始时间的秒数)和 nanos(当前秒的第几纳秒),可以通过以下get开头的方法获取,传入 field 也可以获取毫秒、微秒级的时间。
2.8.2 LocalDate、LocalTime 和 LocalDateTime
字面含义,LocalDate 表示本地日期,LocalTime 表示本地时间,LocalDateTime 表示日期加时间。Java8中支持日期和时间的分别表示。
API都较为简单,来讲两个需要理解的注意点:
●为什么叫“Local”?
Local 表示“本地时间”,即和时区没有关系。比如“你的生日是哪天”,并没有人会说“格林尼治时间的几月几日”,而只是像日历页上的一格,“几月几日”的概念;再比如“新年的钟声几点敲响”,也不会全球在同一时间过新年,而是当地挂钟上的零点,没有时区属性。那什么样的时间不是“Local”的?就是时间线上的一个固定时间点,事情就在那一刻发生了,虽然地球上每个角落的太阳位置不同,墙上挂钟显示的数字也不同,但都是时间这个坐标轴上的同一点。比如北京时间2003年10月15日9时00分03秒497毫秒,神舟五号成功发射,就不是一个“Local”的时间。
LocalDate 可以通过三种方法创建实例:
可以通过各种get方法得到日期相关字段,如字面意思:
可以增减字段值:
以及一些原来要很复杂代码的操作,现在可以简化:
还可以获取指定时区的当前日期时间,或添加时区属性,转化成下面要介绍的 ZonedDateTime,注意这里没有进行时间的时区变换,而是仅仅添加了时区属性,更印证了上文说的“Local”的含义。拿 LocalDateTime 举例:
2.8.3 ZonedDateTime
ZonedDateTime 可以被理解为 LocalDateTime 的外层封装,它的内部存储了一个 LocalDateTime 的实例,专门用于普通的日期时间处理,此外它还定义了 ZoneId 实例和 ZoneOffset 实例来描述时区的概念。调试信息显示如下:
产生 ZonedDateTime 实例的几种方法如下,如字面意思较好理解:
1public static ZonedDateTime now();
2public static ZonedDateTime now(ZoneId zone);
3public static ZonedDateTime of(LocalDate date, LocalTime time, ZoneId zone)
4public static ZonedDateTime of(LocalDateTime localDateTime, ZoneId zone)
5public static ZonedDateTime ofInstant(Instant instant, ZoneId zone)
6public static ZonedDateTime of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone)
其他方法操作和 LocalDateTime 类似,不多赘述。
2.8.4 DateTimeFormatter
DateTimeFormatter 类作为 Java8 中用于表示日期时间的类,与原有DateFormat 类最大的不同就在于它是线程安全的,其他使用上的操作基本类似。举例如下:
2.8.5 Period 和 Duration
Java8 添加了处理时间差的功能,用 Period 处理两个日期之间的差值,用 Duration 处理两个时间之间的差值。between() 方法等大大简化了计算两个日期时间之间差值的操作,举例如下:
2.8.6 Java8 日期时间小结
简单介绍了 Java8 的一些处理日期时间的新API,可以说对比之前的版本是有很大的改进的。
●首先,原有的 Date、Calendar 等类过于泛泛,既可以表示日期又可以表示时间,还能进行时区转换,结果就是各方面都差点意思。Java8 区分了日期和时间的分别表示,使得不同的业务需求有专门对应的数据结构进行设计;
●其次,由于 java.sql.Date、java.sql.Time、java.sql.Timestamp 都继承自 java.util.Date,所以本质上他们都是时区相关的。Java8 区分了本地时间和带时区的时间的表示,ZonedDateTime 的时区转换也非常方便;
●再次,提供了时间差的直接计算方法,不用先换算成Unix时间戳再做减法再做除法等麻烦的步骤;
●最重要的是,他们都是不可变类!!!线程安全!!!
也许你还想看
(▼点击文章标题或封面查看)
2018-08-30
2019-04-18
2018-08-16
加入搜狐技术作者天团
千元稿费等你来!
戳这里!☛
看完啦?留言支持一下再走吧~~~
▼▼▼